跳到主要内容

Java JVM学习-String 字符串

String 的基本特性

String 字符串,使用一对 " " 引起来表示。

// String 有如下两种创建方式
String s1 = "hello world"; // 字面量的定义方式
String s2 = new String("hello world"); // 创建对象的方式

String 实现了 Serializable 接口:表示字符串是支持序列化的。 实现了 Comparable 接口:表示 String 可以比较大小

public final class String
implements java.io.Serializable, Comparable<String>, CharSequence,
Constable, ConstantDesc {

String在 jdk8及以前内部定义了 final char[] value 用于存储字符串数据。jdk9时改为 byte[]

String 声明为 final,所以它不可被继承

String 代表不可变的字符序列。(不可变性)

  • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的 value进行赋值。
  • 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的 value进行赋值。
  • 当调用 String的 replace() 方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的 value进行赋值。

String 不可变性的题目

public class StringExer {
String str = new String("good");
char[] ch = {'t', 'e', 's', 't'};

void change(String str, char[] ch) {
str = "test ok";
ch[0] = 'b';
}

public static void main(String[] args) {
StringExer ex = new StringExer();
ex.change(ex.str, ex.ch);

System.out.println(ex.str); // 输出?
System.out.println(ex.ch); // 输出?
}
}

实际输出为:

good
best

String 底层的 HashTable

通过字面量的方式(区别于 new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。

String s1 = "hello world"; // 字面量的定义方式,"hello world" 存储在字符串常量池里面
String s2 = "hello world"; // 这个 s2 实际和 s1 的是同一个字符串常量

System.out.println(s1 == s2); // 判断地址:true

String s3 = new String("hello world"); // 采用 new 创建的字符串对象不进入字符串池

字符串常量池中是不会存储相同内容的字符串的

String 的 String Pool是一个 固定大小的 Hashtable,默认值大小长度是 1009。如果放进 String Pool的 String非常多,就会造成 Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern 时性能会大幅下降。

在 jdk6中 StringTable 是固定的,就是 1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。但是可以直接设置 StringTableSize,即使用 -XX: StringTablesize 可设置 StringTable 的长度(这个 StringTable 不会自动扩容)

在 jdk7中,StringTable 的长度默认值是 60013, 1009是可设置的最小值。

注:这里的 intern() 方法,用于返回字符串对象的规范化表示形式。具体的后面的小节

String 的内存分配

在 Java语言中有 8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。

常量池就类似一个 Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。

1、直接使用双引号声明出来的String对象会直接存储在常量池中。

String info = "hello world" ;

2、如果不是用双引号声明的 String对象,可以使用 String提供的 intern()方法。

Java6 及以前,字符串常量池存放在永久代。

Java7 中 Oracle 的工程师对字符串池的逻辑做了很大的改变,将字符串常量池的位置调整到 Java堆内。(因为永久代默认比较小,垃圾回收频率低)

  • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
  • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在 Java7中使用 String.intern()

Java8虽然换成了元空间,但是字符串常量还是在堆上

public static void main(String[] args) {
// 使用 Set 保持引用,避免 full GC 回收常量池行为
Set<String> set = new HashSet<>();
// 在 short 可以取值的范围内足以让 6MB 的 PermSize 或 heap产生 OOM了
short i = 0;
while(true) {
set.add(String.valueOf(i++).intern());
}
}

在 JDK8中启动加上:

-XX:MetaspaceSize=6m  -XX:MaxMetaspaceSize=6m  -Xms6m   -Xmx6m

字符串拼接操作

  1. 常量与常量的拼接结果在常量池,原理是编译期优化
  2. 常量池中不会存在相同内容的常量。
  3. 只要其中有一个是变量,结果就在堆中。变量拼接的原理是 StringBuilder
  4. 如果拼接的结果调用 intern() 方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
public static void main(String[] args) {
String s1 = "a" + "b" + "c";
String s2 = "abc";

/*
最终 .java 文件编译出来的 .class 文件里里面是
String s1 = "abc";
String s2 = "abc";
*/
System.out.println(s1 == s2); // true
System.out.println(s1.equals(s2)); // true
}

可以反编译 .class

public class Temp {

public static void main(String[] args) {
String s1 = "javaEE";
String s2 = "hadoop";

String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;

System.out.println(s3 == s4); // true 常量与常量的拼接结果在常量池,原理是编译期优化
System.out.println(s3 == s5); // false 只要其中有一个是变量,则相当于是 new String(),结果就在堆中。变量拼接的原理是 StringBuilder
System.out.println(s3 == s6); // false 理由同上
System.out.println(s3 == s7); // false 理由同上

System.out.println(s5 == s6); // false 变量拼接的原理是 StringBuilder 而这里比对的是地址,所以不一样
System.out.println(s5 == s7); // false 同上

System.out.println(s6 == s7); // false 同上

String s8 = s6.intern();
System.out.println(s3 == s8); // true 将存在堆里面的字符串转到字符常量池
}
}

可以通过字节码文件观察到这块使用的是 StringBuilder 进行的字符拼接,如下示例代码

public class Temp {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4); // false
}
}

检查它的字节码文件

所以上面 s4 那里等价于如下操作:

StringBuilder s = new StringBuilder();
s.append("a");
s.append("b");
String s4 = s.toString(); // 这里类似于 new String("ab");

点进这个 StringBuilder 里面找到它的 toString 方法 ctrl + F12

@Override
@HotSpotIntrinsicCandidate
public String toString() {
// Create a copy, don't share the array
// 这里的 isLatin1 是判断它是否是拉丁文
// 这里的 value 就是 byte[] value; 即维护的那个字符串数组
return isLatin1() ? StringLatin1.newString(value, 0, count)
: StringUTF16.newString(value, 0, count);
}

可以看发现,它实际就是一个 new String 的操作

但是也不是所有的使用变量的字符拼接都是使用的 StringBuilder,当给 String 加上了 final 关键字

如下代码

public static void main(String[] args) {
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4); // true
}

对它的字节码文件反编译之后可以发现它也在编译期就被优化了

append 操作的效率

编写一个测试类,它们的效率便一目了然

public class Temp {

public static void main(String[] args) {
int count = 100000; // 10万次
method1(count); // 花费时间:4403 毫秒
method2(count); // 花费时间:2
}

static void method1(int count) {
long start = System.currentTimeMillis();
String src = "";
for (int i = 0; i < count; i++) {
src = src + "a"; // 每次循环都会创建一个 StringBuilder 对象
}
System.out.println("method1 花费时间:" + (System.currentTimeMillis() - start));
}

static void method2(int count) {
long start = System.currentTimeMillis();
StringBuilder src = new StringBuilder();
for (int i = 0; i < count; i++) {
src.append("a");
}
System.out.println("method2 花费时间:" + (System.currentTimeMillis() - start));
}
}

StringBuilder 的 append() 的方式自始至终中只创建过一个 StringBuilder 的对象,而使用 String 的字符串拼接方式则创建过多个 StringBuilder 和 String 的对象

使用 String的字符串拼接方式由于内存中创建了较多的 StringBuilder 和 String 的对象,所以内存占用更大;

实际上这里的 StringBuilder 还有优化空间,因为 StringBuilder 内部也存在扩容操作,所以可以手动先指定它的大小,避免扩容的开销(不过要数据量大一点才看的出来)

// 这里输入 100000000 一亿次
static void method2(int count) {
long start = System.currentTimeMillis();
StringBuilder src = new StringBuilder(count); // 加上前需要 1064 加上后 741
for (int i = 0; i < count; i++) {
src.append("a");
}
System.out.println("method2 花费时间:" + (System.currentTimeMillis() - start));
}

String 的 intern() 方法

这里转载自 Java intern() 方法

intern() 方法返回字符串对象的规范化表示形式。

// 返回值:一个字符串,内容与此字符串相同,但一定取自具有唯一字符串的池。
// 可以发现它是一个 native 方法
public native String intern();

它遵循以下规则:对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

实例

public class Test {
public static void main(String args[]) {
String Str1 = new String("www.runoob.com");
String Str2 = new String("WWW.RUNOOB.COM");

System.out.print("规范表示:" );
System.out.println(Str1.intern());

System.out.print("规范表示:" );
System.out.println(Str2.intern());
}
}
// 输出为:
// 规范表示:www.runoob.com
// 规范表示:WWW.RUNOOB.COM

尽管在输出中调用 intern 方法并没有什么效果,但是实际上后台这个方法会做一系列的动作和操作。在调用 "ab".intern() 方法的时候会返回 "ab",但是这个方法会首先检查字符串池中是否有 "ab" 这个字符串,如果存在则返回这个字符串的引用,否则就将这个字符串添加到字符串池中,然后返回这个字符串的引用。

可以看下面一个范例:

String str1 = "a";
String str2 = "b";
String str3 = "ab";
String str4 = str1 + str2;
String str5 = new String("ab");

System.out.println(str5.equals(str3)); // true
System.out.println(str5 == str3); // false
System.out.println(str5.intern() == str3); // true
System.out.println(str5.intern() == str4); // false

为什么会得到这样的一个结果呢?我们一步一步的分析。

第一、str5.equals(str3) 这个结果为 true,不用太多的解释,因为字符串的值的内容相同。

第二、str5 == str3 对比的是引用的地址是否相同,由于 str5 采用 new String 方式定义的,所以地址引用一定不相等。所以结果为false。

第三、当 str5 调用 intern 的时候,会检查字符串池中是否含有该字符串。由于之前定义的 str3 已经进入字符串池中,所以会得到相同的引用。

第四,当 str4 = str1 + str2 后,str4的值也为 "ab",但是为什么这个结果会是 false 呢?

先看下面代码:

String a = new String("ab");
String b = new String("ab");
String c = "ab";
String d = "a" + "b";
String e = "b";
String f = "a" + e;

System.out.println(b.intern() == a); // false
System.out.println(b.intern() == c); // true
System.out.println(b.intern() == d); // true
System.out.println(b.intern() == f); // false
System.out.println(b.intern() == a.intern()); // true

由运行结果可以看出来,b.intern() == ab.intern() == c 可知,采用 new 创建的字符串对象不进入字符串池,并且通过 b.intern() == db.intern() == f 可知,字符串相加的时候,都是静态字符串的结果会添加到字符串池,如果其中含有变量(如 f 中的 e)则不会进入字符串池中。但是字符串一旦进入字符串池中,就会先查找池中有无此对象。如果有此对象,则让对象引用指向此对象。如果无此对象,则先创建此对象,再让对象引用指向此对象。

当研究到这个地方的时候,突然想起来经常遇到的一个比较经典的 Java问题,就是对比 equal== 的区别,当时记得老师只是说 == 判断的是 “地址”,但是并没说清楚什么时候会有地址相等的情况。

现在看来,在定义变量的时候赋值,如果赋值的是静态的字符串(字面量的创建方式),就会执行进入字符串池的操作,如果池中含有该字符串,则返回引用。

执行下面的代码:

String a = "abc";
String b = "abc";
String c = "a" + "b" + "c";
String d = "a" + "bc";
String e = "ab" + "c";

System.out.println(a == b); // true
System.out.println(a == c); // true
System.out.println(a == d); // true
System.out.println(a == e); // true
System.out.println(c == d); // true
System.out.println(c == e); // true

new String() 创建了几个对象?

new String("ab") 创建了几个对象?

实际上创建了两个对象,一个是 String 本身,另一个就是 "ab" 会被存在字符串常量池里面

如下代码

public class Temp {
public static void main(String[] args) {
String s1 = new String("ab");
}
}

检查它的字节码指令

可以发现先是创建一个 String 对象,然后可以发现它是从常量池里面取得的这个 "ab" 表明它早就在常量池里面了

new String("a") + new String("b") 呢?

如下代码

public class Temp {
public static void main(String[] args) {
String s1 = new String("a") + new String("b");
}
}

检查它的字节码指令

执行逻辑:

  1. 首先它创建了一个 StringBuilder
  2. 然后又 new String("a"),同理,这里也同样引用了一个常量 "a"
  3. 调用 StringBuilder 的 append 方法把这个 String 添加进去
  4. 然后创建一个 new String("b"),引用了常量 "b"
  5. 调用 StringBuilder 的 append 方法把这个 String 添加进去
  6. 最后调用 StringBuilder 的 toString 方法(等价于 new String("ab")

所以它等价于

String s1 = new String("ab");

intern() 的面试题 ⭐

注意,这段代码在 JDK 不同版本的执行结果是不一样的(JDk6 和 JDK7)

public static void main(String[] args) {
String s = new String("1");
s.intern(); // 调用此方法之前字符串常量池里面已经存在 "1" 了
String s2 = "1"; // 这个 "1" 其实就是 new String("1"); 这里的常量

// 这里之所以不一样是因为上面的 s 并没有接收 intern() 的返回值(常量池的地址),它指向的还是堆里的地址
System.out.println(s == s2); // false

// ===========================题目分割线=============================================

String s3 = new String("1") + new String("1"); // 由上面那一节可知,这里实际就等于 new String("11")

// 执行完上一行代码后,字符串常量池中是否存在 11 呢? 答案:不存在
s3.intern(); // 所以这里的在字符串常量池中生成 11。如何理解?
// jdk6:在永久代创建了一个新的对象 11,也就有了新的地址
// jdk7:因为常量池已经移到堆里面来了,为了节省空间,常量池中并没有创建 11,而是直接让常量池指向 s3 的堆空间

String s4 = "11"; // s4记录的地址:使用的是上一行代码执行时,在常量池中生成的 11 的地址,即 s3 的地址
System.out.println(s3 == s4); // jdk6:false jdk7:true
}

JDK 6 及以前

false
false

JDK 7 及以后

false
true

不同版本的 intern() 使用

由上面的面试题可知

总结 String 的 intern() 的使用:

JDK1.6 中,将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
  • 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址

JDK1.7起,将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
  • 如果没有,则会把对象的 引用地址复制一份,放入串池,并返回串池中的引用地址